Skip to content

Fix yarn renamed-copy identity fail-open (#1516) - #1517

Merged
Mikola Lysenko (mikolalysenko) merged 7 commits into
mainfrom
agent/fix-yarn-copy-identity-fail-open
Oct 14, 2026
Merged

Mikola Lysenko (mikolalysenko) merged 7 commits into
mainfrom
agent/fix-yarn-copy-identity-fail-open

Conversation

@mikolalysenko

@mikolalysenko Mikola Lysenko (mikolalysenko) commented Oct 14, 2026 •

Copy link
Copy Markdown
Collaborator

LLM Description written by Claude Code:claude-opus-5-5

Fixes #1516

Summary

A renamed yarn copy ("lp2": "file:../ext/lpdir", locked as lp2@file:../ext/lpdir) is now identified, or warned about, in three shapes that used to slip through:

  • a file: copy outside the project root
  • a file: tarball whose top directory isn't package/
  • a non-registry URL tarball

Before this fix, a hosted or vendored scan exited 0 with success and VEX attested not_affected for left-pad@1.3.0, while node_modules/lp2 installed it unpatched. Now:

  • hosted mode warns redirect_copy_unpatched
  • vendored mode names the copy
  • vex and the in-run --vex don't attest the package from that lock

Root cause

#1489 identifies a renamed copy by reading the copy itself (formats::yarn::copy::CopyRef). Hosted, vendored and vex all share that reader, and every caller treated a copy it couldn't read as "not this package" (fail-open):

  1. classic_copy_ref dropped any file: path that leaves the root (normalize_rel("", path)?).
  2. tarball_manifest_name used the lenient archive reader, which strips only a literal package/ prefix. yarn strips the first path segment whatever its name.
  3. A non-registry URL names no package, so it came back as unknown and was skipped.

Fix

  • copy.rs: a copy's path keeps its ../ escapes and is read where yarn installs it from.
    • yarn 1 writes every classic file: key relative to the root and normalized, whichever package.json declared it (checked with 1.22.22: a member's file:../../../ext/lpdir locks as lp2@file:../ext/lpdir), so each one is read from the root.
    • Berry paths were already resolved against their locator= workspace.
    • An absolute path is never read. A lock is untrusted input, and an absolute path could name any file or a Windows UNC share. Such a copy stays unidentified.
  • patch/package.rs: new read_archive_bytes_to_map_any_root, which strips the first segment the way npm-family installers do. A member spelled under two different roots is dropped as ambiguous.
  • Fail closed for a copy whose package can't be read (non-portal:/link:):
    • vex discovery records it with Discovery::unidentified_copy. contest_within_locks matches it once every extractor has pushed its refs, as an unpatched copy of each package that lock wires at the copy's version. Hosted mode reads the same result, so it warns redirect_copy_unpatched and keeps the in-run VEX from attesting.
    • vendor::yarn_copies::other_name_copies returns OtherNameCopy { identified }. An unidentified copy is named as one that "may be" the package, but it never turns vendor_lock_entry_not_found into not_rewritable: it doesn't prove the package is installed.
  • docs/ecosystems.md documents the behavior.

Tests (red on main, green here)

Issue shape Test Red without fix Green
#1516 outside-root file: dir/tarball, left-pad-1.3.0/ tarball root, non-registry URL, absolute path (vex/hosted discovery, hosted and vendored wirings) vex::discover::yarn::tests::issue_1516_unread_classic_copies_contest_the_ref ✗ ✓
#1516 berry: unidentified copy vs a resolutions-confirmed vendored ref vex::discover::yarn::tests::issue_1516_unread_berry_copy_contests_a_vendored_ref (new path) ✓
#1516 vendored classic names all three shapes vendor::yarn_classic_lock::tests::issue_1516_unread_other_name_copies_are_named ✗ ✓
#1516 unidentified copy alone keeps not_found vendor::yarn_classic_lock::tests::issue_1516_unread_copy_alone_is_not_found guard ✓
#1516 tarball any root / path keeping formats::yarn::copy::tests::{tarball_identity_reads_any_root_directory, classic_refs_split_tarballs_directories_and_urls} ✗ ✓
#1516 end to end with real yarn 1.22.22 (issue repro: hosted scan + in-run VEX + lock-only vex) e2e_redirect_yarn_classic_build::classic_outside_root_copy_under_another_name_is_named_and_not_attested ✗ (on main: status: success, 1 VEX statement) ✓

Commands run locally:

  • cargo fmt --all -- --check: ok
  • cargo clippy --locked --workspace --all-features -- -D warnings (CI's command): ok
  • cargo test --workspace --all-features --lib --bins: all pass except 4 tests outside this diff (copy_tree::relax_loop_must_not_traverse_symlinked_root, vlt_heal::an_unremovable_hidden_lock_keeps_every_store_entry, pypi_poetry::wire_write_failure_…, pypi_requirements::wire_failure_…). Each relies on a read-only directory blocking a write, and this sandbox runs as uid 0. They don't touch yarn code.
  • With SOCKET_PATCH_YARN_CLASSIC_E2E_VERSION=1.22.22: e2e_redirect_yarn_classic_build 40/40, e2e_vendor_yarn_classic_build 40/40, e2e_vendor_yarn_classic_dev_flow 36/36.
  • The full cargo test --workspace (every integration binary) doesn't fit this session's disk allowance (link fails with ENOSPC). CI runs it.
  • Wrappers under npm/, pypi/, gem/ only dispatch to the binary, so nothing to change there.

/code-review high findings

Fixed:

  • An unidentified berry copy didn't contest vendored or resolutions-keyed refs, because those are pushed after the copies are read. Matching now happens in contest_within_locks. It also runs once per run (version index) instead of copies × refs.
  • A workspace member's file:../b was suspected of being read relative to the wrong directory. A reviewer then showed that yarn 1 writes these keys root-relative, and the gate I first added for it dropped real copies, so it has been removed (75d5610).
  • Absolute or UNC lock paths were opened. They're never read now.

Not changed, with reasons:

  • Over-contest: an unreadable copy blocks attestation of every package the lock wires at its version. That's intended: the copy could be any of them, and the issue asks for fail-closed. The cost is a warning, not a false not_affected.
  • In-memory hosted engine can't read outside-root copies: it has no outside files, so such a copy is unidentified and fails closed there. That's the safe direction.
  • Hosted classic rewriter's own classic_file_directory skip doesn't fire for outside-root paths: the engine's discovery pass names the copy with redirect_copy_unpatched (asserted by the e2e). Unifying the rewriter's resolver with CopyRef is a refactor beyond this fix.
  • ../proj/.socket/… spelling of our own wiring: yarn writes .socket wiring root-relative (file:./.socket/…). A lock that spells it through ../<own-dir> isn't something socket-patch or yarn produces.
  • Duplicated "may be" wording in the classic and berry backends: each backend keeps its own warning text, as before.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WyDjcHE4ZNtbUocsBEJvFH


Generated by Claude Code

Assisted-by: Claude Code:claude-opus-5-5
A renamed yarn copy from a file: path outside the project, a tarball
whose top directory isn't package/, or a non-registry URL was taken
for "not this package". The scan then succeeded and VEX attested
not_affected while that copy installed unpatched (#1516).

Read outside-root copies where yarn installs them from, strip a
tarball's first path segment as yarn does, and fail closed: an
unreadable copy of the same version is named as a possible copy and
blocks attestation of that version from the lock.

WIP: tests written, not yet run.

Assisted-by: Claude Code:claude-opus-5-5
Describe in docs/ecosystems.md how renamed yarn copies outside the
project, tarballs with any top-level directory, and copies whose
package can't be read are treated (#1516).

Assisted-by: Claude Code:claude-opus-5-5
Assisted-by: Claude Code:claude-opus-5-5
Match a copy whose package can't be read against the lock's refs
once every extractor is done, so berry vendored and resolutions refs
pushed after the copies are read are contested too, in one pass.

Take a classic file: path that leaves the root as root-relative only
when the root package.json declares it: yarn 1 writes a workspace
member's "file:../b" relative to that member, so reading it from the
root would name the wrong package or block attestation for nothing.

Never read an absolute copy path from a lock, which could name any
file or a Windows network share; such a copy stays unidentified.

Assisted-by: Claude Code:claude-opus-5-5
The CLI builds Discovery with struct update syntax, so a private field
broke its build.

Assisted-by: Claude Code:claude-opus-5-5
@mikolalysenko
Mikola Lysenko (mikolalysenko) marked this pull request as ready for review October 14, 2026 03:21
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

BugBot review


Generated by Claude Code

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 6289505. Configure here.

Comment thread crates/socket-patch-core/src/formats/yarn/copy.rs Outdated
yarn 1 writes a file: lock key relative to the project root and
normalized, whichever package.json declared it: a workspace member's
"file:../../../ext/lpdir" locks as "lp2@file:../ext/lpdir", and the
root's "file:../ext/lpdir/" loses its trailing slash. The gate that
took an outside-root key as the root's own only when the root
package.json spelled it exactly dropped both, so the unpatched copy
went unwarned and vex attested it again. Drop the gate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WyDjcHE4ZNtbUocsBEJvFH
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants